/* Copyright (c) 2011 William Steven Knauer
 * 
 * Permission is hereby granted, free of charge, to any person obtaining a copy of this software
 * and associated documentation files (the "Software"), to deal in the Software without restriction,
 * including without limitation the rights to use, copy, modify, merge, publish, distribute,
 * sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 * 
 * The above copyright notice and this permission notice shall be included in all copies or
 * substantial portions of the Software.
 * 
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
 * NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
 * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
 * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 */

package com.williamknauer.android.multitouchdetector;

import android.util.Log;
import android.view.MotionEvent;

/**
 * Interprets MotionEvents (see {@link android.view.MotionEvent}) and fires
 * callbacks to notify when gestures have occurred, such as taps/clicks,
 * drags/scrolls, and scales/zooms. Calls methods from a supplied
 * {@link MultitouchListener} to notify gestures have occurred.
 * 
 * TODO: Refactor! This code has gotten pretty ugly since v1.1.
 * 
 * CHANGELOG:
 *     2011.0903 v1.0: Drag, scale, and tap gestures working as intended
 *                     with MultitouchListener v1.0.
 *     2011.0904 v1.1: Made necessary changes to maintain compatibility with
 *                     MultitouchListener v1.1.
 *     2011.0908 v1.2: Removed possibility of simultaneous single taps because
 *                     this functionality is outside the problem domain the
 *                     software is intended to address. Now multitouch begin
 *                     callbacks are fired as soon as a secondary pointer
 *                     goes down.
 * 
 * @author William S Knauer <knauerw1@gmail.com>
 * @version 2011.0908
 */
public class MultitouchDetector {

	/**
	 * Inner class of {@link MultitouchDetector} used to help track the state of
	 * a pointer.
	 * 
	 * @author William S Knauer <knauerw1@gmail.com>
	 * @version 2011.0903
	 */
	private class PointerState {

		/**
		 * The maximum number of px a pointer can move while still being
		 * considered a tap
		 */
		private static final float SINGLE_TAP_TOLERANCE = 20.0f;

		/**
		 * The maximum time in ms a pointer can be down while still being
		 * considered a tap
		 */
		private static final long SINGLE_TAP_MAX_TIME = 325;

		/** An identifier unique to this pointer */
		private int mId;

		/** The system time when this pointer was last initialized */
		private long mLifetime;

		/** The horizontal position where this pointer was started */
		private float mStartX;

		/** The vertical position where this pointer was started */
		private float mStartY;

		/** The current horizontal position of this pointer */
		private float mX;

		/** The current vertical position of this pointer */
		private float mY;

		/** The previous horizontal position of this pointer */
		private float mPrevX;

		/** The previous vertical position of this pointer */
		private float mPrevY;

		/**
		 * The number of times this pointer has been started since its last
		 * initialization
		 */
		private int mStartCount;

		PointerState() {
			reset();
		}

		/**
		 * @return The difference between this pointer's current horizontal
		 *         position and its previous horizontal position
		 */
		float getDeltaX() {
			return mX - mPrevX;
		}

		/**
		 * @return The difference between this pointer's current vertical
		 *         position and its previous vertical position
		 */
		float getDeltaY() {
			return mY - mPrevY;
		}

		/**
		 * @return The unique id of this pointer relative to other active
		 *         pointers
		 */
		int getId() {
			return mId;
		}

		/**
		 * @return The previous horizontal position of this pointer
		 */
		float getPrevX() {
			return mPrevX;
		}

		/**
		 * @return The previous vertical position of this pointer
		 */
		float getPrevY() {
			return mPrevY;
		}

		/**
		 * @return The horizontal position where this pointer's gesture was
		 *         started
		 */
		float getStartX() {
			return mStartX;
		}

		/**
		 * @return The vertical position where this pointer's gesture was
		 *         started
		 */
		float getStartY() {
			return mStartY;
		}

		/**
		 * @return The current horizontal position of this pointer
		 */
		float getX() {
			return mX;
		}

		/**
		 * @return The current vertical position of this pointer
		 */
		float getY() {
			return mY;
		}

		/**
		 * Logs a unique identifier and the current time while resetting all
		 * other state data.
		 * 
		 * @param id
		 *            The unique identifier of this pointer
		 */
		void initialize(int id) {
			reset();
			mId = id;
			mLifetime = System.currentTimeMillis();
		}

		/**
		 * @return Whether or not this pointer is currently initialized
		 */
		boolean isInitialized() {
			return mId >= 0;
		}

		/**
		 * @return Whether or not this pointer could generate a single tap
		 */
		boolean isSingleTapEligible() {
			if (System.currentTimeMillis() - mLifetime > SINGLE_TAP_MAX_TIME) {
				return false;
			} else if (Math.abs(mX - mStartX) > SINGLE_TAP_TOLERANCE) {
				return false;
			} else if (Math.abs(mY - mStartY) > SINGLE_TAP_TOLERANCE) {
				return false;
			} else if (mStartCount > 1) {
				return false;
			} else {
				return true;
			}
		}

		/**
		 * Adjusts the current position to the given coordinates and logs the
		 * previous position
		 * 
		 * @param x
		 *            The new horizontal position
		 * @param y
		 *            The new vertical position
		 */
		void moveTo(float x, float y) {
			mPrevX = mX;
			mPrevY = mY;
			mX = x;
			mY = y;
		}

		/**
		 * Resets all state data to that of a newly instantiated
		 * {@link PointerState}.
		 */
		void reset() {
			mId = -1;
			mLifetime = -1;
			mStartX = -1.0f;
			mStartY = -1.0f;
			mX = -1.0f;
			mY = -1.0f;
			mPrevX = -1.0f;
			mPrevY = -1.0f;
			mStartCount = 0;
		}

		/**
		 * Sets state data to according to the start of a new gesture.
		 * 
		 * @param startX
		 *            The horizontal position of this pointer at the start of
		 *            the current gesture
		 * @param startY
		 *            The vertical position of this pointer at the start of the
		 *            current gesture
		 */
		void start(float startX, float startY) {
			mStartX = startX;
			mStartY = startY;
			mX = startX;
			mY = startY;
			mPrevX = startX;
			mPrevY = startY;
			mStartCount++;
		}
	}

	/** A tag used to identify this object in the debugging log */
	private static final String TAG = "MultitouchDetector";

	/**
	 * Calculates and returns the magnitude of the vector between two points.
	 * 
	 * @param x1
	 *            The horizontal position of the first point
	 * @param y1
	 *            The vertical position of the first point
	 * @param x2
	 *            The horizontal position of the second point
	 * @param y2
	 *            The vertical position of the second point
	 * 
	 * @return The magnitude of the vector formed by the given points
	 */
	private static float findMagnitude(float x1, float y1, float x2, float y2) {
		x2 -= x1;
		y2 -= y1;
		return (float) Math.sqrt(x2 * x2 + y2 * y2);
	}

	/** The listener used to fire gesture notification callbacks */
	private MultitouchListener mListener;

	/** An object containing state information about the primary pointer */
	private PointerState mPrimaryPtr;

	/** An object containing state information about the secondary pointer */
	private PointerState mSecondaryPtr;

	/** A flag indicating whether or not a single pointer drag is in progress */
	private boolean mSingleDrag = false;

	/**
	 * Class constructor. This is the only constructor available for this class.
	 * It requires a {@link MultitouchListener} to notify when a gesture has
	 * occurred.
	 * 
	 * @param listener
	 *            The listener that will receive gesture callbacks from this
	 *            class
	 */
	public MultitouchDetector(MultitouchListener listener) {
		mListener = listener;
		mPrimaryPtr = new PointerState();
		mSecondaryPtr = new PointerState();
	}

	/**
	 * Handles a {@link android.view.MotionEvent} whose action is
	 * {@link android.view.MotionEvent.ACTION_CANCEL}. Resets all state
	 * information to be the same as having no pointers down. Fires callbacks to
	 * notify the finish of any gestures that were in progress.
	 * 
	 * @param event
	 *            The event to be handled
	 * @return true if the event is handled
	 */
	private boolean onActionCancel(MotionEvent event) {
		if (mSecondaryPtr.isInitialized()) {
			// copy state data locally
			float x1 = mPrimaryPtr.getX();
			float y1 = mPrimaryPtr.getY();
			float x2 = mSecondaryPtr.getX();
			float y2 = mSecondaryPtr.getY();
			float focusX = (x1 + x2) / 2.0f;
			float focusY = (y1 + y2) / 2.0f;
			float sx1 = mPrimaryPtr.getStartX();
			float sy1 = mPrimaryPtr.getStartY();
			float sx2 = mSecondaryPtr.getStartX();
			float sy2 = mSecondaryPtr.getStartY();
			float sFocusX = (sx1 + sx2) / 2.0f;
			float sFocusY = (sy1 + sy2) / 2.0f;
			float totalDistX = focusX - sFocusX;
			float totalDistY = focusY - sFocusY;
			float sMagnitude = findMagnitude(sx1, sy1, sx2, sy2);
			float magnitude = findMagnitude(x1, y1, x2, y2);
			float totalScale = magnitude / sMagnitude;

			// fire callbacks
			mListener.onMultiDragEnd(x1, y1, x2, y2, focusX, focusY, totalDistX, totalDistY);
			mListener.onScaleEnd(x1, y1, x2, y2, focusX, focusY, totalScale);
		}
		if (mSingleDrag) {
			// copy state data locally
			float x = mPrimaryPtr.getX();
			float y = mPrimaryPtr.getY();
			float sx = mPrimaryPtr.getStartX();
			float sy = mPrimaryPtr.getStartY();
			float totalDistX = x - sx;
			float totalDistY = y - sy;

			// fire callback
			mListener.onSingleDragEnd(x, y, totalDistX, totalDistY);
		}
		mSingleDrag = false;
		mPrimaryPtr.reset();
		mSecondaryPtr.reset();
		return true;
	}

	/**
	 * Handles a {@link android.view.MotionEvent} whose action is
	 * {@link android.view.MotionEvent.ACTION_DOWN}.
	 * 
	 * @param event
	 *            The event to be handled
	 * @return true if the event is handled
	 */
	private boolean onActionDown(MotionEvent event) {
		// initialize and start mPrimaryPointer
		int ptrIndex = 0;
		int ptrId = event.getPointerId(ptrIndex);
		mPrimaryPtr.initialize(ptrId);
		mPrimaryPtr.start(event.getX(ptrIndex), event.getY(ptrIndex));
		return true;
	}

	/**
	 * Handles a {@link android.view.MotionEvent} whose action is
	 * {@link android.view.MotionEvent.ACTION_MOVE}.
	 * 
	 * @param event
	 *            The event to be handled
	 * @return true if the event is handled
	 */
	private boolean onActionMove(MotionEvent event) {
		boolean result = false;
		if (mSecondaryPtr.isInitialized()) {
			result = onActionMoveMulti(event); // handle two-pointer move in
												// another method
		} else {
			// move the pointer
			int ptrId = mPrimaryPtr.getId();
			int ptrIndex = event.findPointerIndex(ptrId);
			float x = event.getX(ptrIndex);
			float y = event.getY(ptrIndex);
			mPrimaryPtr.moveTo(x, y);

			if (mSingleDrag) { // if we are known to be dragging
				float dx = mPrimaryPtr.getDeltaX();
				float dy = mPrimaryPtr.getDeltaY();
				// fire callback notifying a single-pointer drag
				result = (result || mListener.onSingleDrag(x, y, dx, dy));
			} else if (!mPrimaryPtr.isSingleTapEligible()) { // down and
																// ineligible
																// for tap ==
																// drag
				float sx = mPrimaryPtr.getStartX();
				float sy = mPrimaryPtr.getStartY();
				// fire callbacks to start drag
				result = (result || mListener.onSingleDragBegin(sx, sy));
				result = (result || mListener.onSingleDrag(x, y, x - sx, y - sy));
				mSingleDrag = true;
			}
		}
		return result;
	}

	/**
	 * Handles a {@link android.view.MotionEvent} whose action is
	 * {@link android.view.MotionEvent.ACTION_MOVE} and could not be handled by
	 * {@link onActionMove(MotionEvent)} because it involves multiple pointers.
	 * 
	 * @param event
	 *            The event to be handled
	 * @return true if the event is handled
	 */
	private boolean onActionMoveMulti(MotionEvent event) {
		// move primary pointer
		int primaryPtrId = mPrimaryPtr.getId();
		int primaryPtrIndex = event.findPointerIndex(primaryPtrId);
		float x1 = event.getX(primaryPtrIndex);
		float y1 = event.getY(primaryPtrIndex);
		mPrimaryPtr.moveTo(x1, y1);

		// move secondary pointer
		int secondaryPtrId = mSecondaryPtr.getId();
		int secondaryPtrIndex = event.findPointerIndex(secondaryPtrId);
		float x2 = event.getX(secondaryPtrIndex);
		float y2 = event.getY(secondaryPtrIndex);
		mSecondaryPtr.moveTo(x2, y2);

		// calculate distance midpoint was dragged
		float pFocusX = (mPrimaryPtr.getPrevX() + mSecondaryPtr.getPrevX()) / 2.0f;
		float pFocusY = (mPrimaryPtr.getPrevY() + mSecondaryPtr.getPrevY()) / 2.0f;
		float focusX = (x1 + x2) / 2.0f;
		float focusY = (y1 + y2) / 2.0f;
		float dx = focusX - pFocusX;
		float dy = focusY - pFocusY;

		// calculate scale change
		float mag = findMagnitude(x1, y1, x2, y2);
		float prevMag =
				findMagnitude(mPrimaryPtr.getPrevX(), mPrimaryPtr.getPrevY(),
						mSecondaryPtr.getPrevX(), mSecondaryPtr.getPrevY());
		float scale = mag / prevMag;
		
		// fire multitouch callbacks
		mListener.onMultiDrag(x1, y1, x2, y2, focusX, focusY, dx, dy);
		mListener.onScale(x1, y1, x2, y2, focusX, focusY, scale);
		
		return true;
	}

	/**
	 * Handles a {@link android.view.MotionEvent} whose action is
	 * {@link android.view.MotionEvent.ACTION_POINTER_DOWN}.
	 * 
	 * @param event
	 *            The event to be handled
	 * @return true if the event is handled
	 */
	private boolean onActionPointerDown(MotionEvent event) {
		if (mSecondaryPtr.isInitialized()) { // ignore tertiary pointer
			return false;
		} else { // initialize and start secondary pointer
			int ptrIndex = event.getActionIndex();
			int ptrId = event.getPointerId(ptrIndex);
			mSecondaryPtr.initialize(ptrId);
			mSecondaryPtr.start(event.getX(ptrIndex), event.getY(ptrIndex));
			if (mSingleDrag) { // end single drag if in progress
				// copy state vars locally
				float x = mPrimaryPtr.getX();
				float y = mPrimaryPtr.getY();
				float totalDistX = x - mPrimaryPtr.getStartX();
				float totalDistY = y - mPrimaryPtr.getStartY();

				// fire callback
				mListener.onSingleDragEnd(x, y, totalDistX, totalDistY);
				mSingleDrag = false;
			}
			
			// copy state information locally
			int primaryPtrId = mPrimaryPtr.getId();
			int primaryPtrIndex = event.findPointerIndex(primaryPtrId);
			float x1 = event.getX(primaryPtrIndex);
			float y1 = event.getY(primaryPtrIndex);
			int secondaryPtrId = mSecondaryPtr.getId();
			int secondaryPtrIndex = event.findPointerIndex(secondaryPtrId);
			float x2 = event.getX(secondaryPtrIndex);
			float y2 = event.getY(secondaryPtrIndex);
			float focusX = (x1 + x2) / 2.0f;
			float focusY = (y1 + y2) / 2.0f;
			
			// fire callbacks to begin multitouch gestures
			mListener.onMultiDragBegin(x1, y1, x2, y2, focusX, focusY);
			mListener.onScaleBegin(x1, y1, x2, y2, focusX, focusY);
			return true;
		}
	}

	/**
	 * Handles a {@link android.view.MotionEvent} whose action is
	 * {@link android.view.MotionEvent.ACTION_POINTER_UP}.
	 * 
	 * @param event
	 *            The event to be handled
	 * @return true if the event is handled
	 */
	private boolean onActionPointerUp(MotionEvent event) {
		boolean result = false;
		int ptrIndex = event.getActionIndex();
		int ptrId = event.getPointerId(ptrIndex);

		// switch primary and secondary pointers if primary went up first
		if (ptrId == mPrimaryPtr.getId()) {
			PointerState temp = mPrimaryPtr;
			mPrimaryPtr = mSecondaryPtr;
			mSecondaryPtr = temp;
		}

		if (ptrId == mSecondaryPtr.getId()) {
			// fire callbacks to end multitouch gestures
			
			// copy state data locally
			float x1 = mPrimaryPtr.getX();
			float y1 = mPrimaryPtr.getY();
			float x2 = mSecondaryPtr.getX();
			float y2 = mSecondaryPtr.getY();
			float focusX = (x1 + x2) / 2.0f;
			float focusY = (y1 + y2) / 2.0f;
			float sx1 = mPrimaryPtr.getStartX();
			float sy1 = mPrimaryPtr.getStartY();
			float sx2 = mSecondaryPtr.getStartX();
			float sy2 = mSecondaryPtr.getStartY();
			float sFocusX = (sx1 + sx2) / 2.0f;
			float sFocusY = (sy1 + sy2) / 2.0f;
			float totalDistX = focusX - sFocusX;
			float totalDistY = focusY - sFocusY;
			float sMagnitude = findMagnitude(sx1, sy1, sx2, sy2);
			float magnitude = findMagnitude(x1, y1, x2, y2);
			float totalScale = magnitude / sMagnitude;

			boolean drag =
					mListener.onMultiDragEnd(x1, y1, x2, y2, focusX, focusY, totalDistX,
							totalDistY);
			boolean scale = mListener.onScaleEnd(x1, y1, x2, y2, focusX, focusY, totalScale);
			result = (result || drag || scale);

			// reset lifted pointer and restart gesture on pointer still down
			mSecondaryPtr.reset();
			mPrimaryPtr.start(mPrimaryPtr.getX(), mPrimaryPtr.getY());
		}
		return result;
	}

	/**
	 * Handles a {@link android.view.MotionEvent} whose action is
	 * {@link android.view.MotionEvent.ACTION_UP}.
	 * 
	 * @param event
	 *            The event to be handled
	 * @return true if the event is handled
	 */
	private boolean onActionUp(MotionEvent event) {
		boolean result = false;
		if (mPrimaryPtr.isSingleTapEligible()) { // fire callback for single tap
			int x = Math.round(mPrimaryPtr.getX());
			int y = Math.round(mPrimaryPtr.getY());
			result = (result || mListener.onSingleTap(x, y));
		} else if (mSingleDrag) { // fire callback to end drag
			// copy state vars locally
			float x = mPrimaryPtr.getX();
			float y = mPrimaryPtr.getY();
			float totalDistX = x - mPrimaryPtr.getStartX();
			float totalDistY = y - mPrimaryPtr.getStartY();

			// fire callback
			result = (result || mListener.onSingleDragEnd(x, y, totalDistX, totalDistY));
		}

		// reset lifted pointer
		mSingleDrag = false;
		mPrimaryPtr.reset();
		return result;
	}

	/**
	 * Interprets {@link android.view.MotionEvent}s fire gesture callbacks. This
	 * method should be called from the {@code onTouchEvent(MotionEvent)} method
	 * in {@link android.app.Activity}.
	 * 
	 * @param event
	 *            The event to be handled
	 * @return true if the event is handled
	 */
	public boolean onTouchEvent(MotionEvent event) {
		// dispatch event to correct handler
		switch (event.getActionMasked()) {
		case MotionEvent.ACTION_CANCEL:
			return onActionCancel(event);
		case MotionEvent.ACTION_DOWN:
			return onActionDown(event);
		case MotionEvent.ACTION_MOVE:
			return onActionMove(event);
		case MotionEvent.ACTION_POINTER_DOWN:
			return onActionPointerDown(event);
		case MotionEvent.ACTION_POINTER_UP:
			return onActionPointerUp(event);
		case MotionEvent.ACTION_UP:
			return onActionUp(event);
		default:
			Log.d(TAG, "Unhandled MotionEvent. Event action is " + event.getActionMasked()
					+ " on pointer " + event.getActionIndex() + ".");
			return false;
		}
	}
}
